外部活动导入管理 API

概述

该接口用于从外部票务平台(Eventbrite、DICE.fm、PoshVIP、RA.co、Shotgun)导入活动数据到 Katana 系统。接口通过网页抓取/API 调用技术获取活动信息,并自动创建活动实体及关联的票务产品。

[!warning] 管理员接口 此接口仅限管理员使用,需要通过 Pear-Client-IdPear-Client-Secret 进行身份验证。

[!info] 核心能力

  • 自动抓取外部平台活动数据
  • 智能解析活动信息(标题、时间、场地、票价)
  • 上传并处理活动海报
  • 自动计算手续费和票价分离
  • 一键创建完整活动及票务产品

接口信息

属性
请求方式 POST
请求路径 /external-event-import/admin/:userId
内容类型 application/json
认证方式 AdminGuard(Header 认证)
代码位置 src/web-scraper/admin/event-scraper.admin.controller.ts:50

请求头 (Headers)

Header 名称 类型 必填 说明
Content-Type string 固定值:application/json
Pear-Client-Id string 管理员客户端 ID(从环境变量 PEAR_CLIENT_ID 获取)
Pear-Client-Secret string 管理员客户端密钥(从环境变量 PEAR_CLIENT_SECRET 获取)
timezone string 时区信息(例如:Asia/Shanghai

[!note] 认证实现 认证逻辑位于 src/auth/admin.guard.ts:15,通过比对 Header 中的凭据与环境变量进行验证。


路径参数 (Path Parameters)

参数名 类型 必填 说明
userId string (UUID) 目标用户的 ID,活动将关联到此用户

请求体 (Body)

EventImportRequest

代码位置: src/web-scraper/web-scraper.interface.ts:19

{
  type: EventImportType;
  url: string;
}
字段名 类型 必填 说明
type string (enum) 活动来源平台类型,见下方枚举值
url string 活动 URL 地址(支持带或不带协议前缀)

EventImportType 枚举值

代码位置: src/web-scraper/web-scraper.interface.ts:11

平台 URL 格式要求 抓取方式
eventbrite Eventbrite https://www.eventbrite.com/e/* 页面 window.__SERVER_DATA__
dicefm DICE.fm https://dice.fm/event/* Meta 标签 + API
poshvip PoshVIP https://posh.vip/e/* self.__next_f.push
raco RA.co 待补充 待补充
shotgun Shotgun 待补充 待补充

响应结构

成功响应

HTTP 状态码: 200 OK

响应体: void(无返回内容)

[!note] 响应说明 接口执行成功后不返回具体内容。活动是否创建成功需要通过其他接口查询确认(如 GET /product-event/:id/details)。

错误响应格式

{
  "statusCode": 400,
  "message": "错误描述",
  "error": "Bad Request"
}

业务流程详解

流程图

graph TD
    A[接收请求] --> B[验证用户存在性]
    B --> C[验证并规范化 URL]
    C --> D{平台类型判断}
    D -->|eventbrite| E[Eventbrite 抓取流程]
    D -->|dicefm| F[DICE.fm 抓取流程]
    D -->|poshvip| G[PoshVIP 抓取流程]
    D -->|其他| H[抛出不支持错误]

    E --> I[转换为标准数据格式]
    F --> I
    G --> I

    I --> J[上传海报到 Cloudinary]
    J --> K[处理时区信息]
    K --> L[计算票价和手续费]
    L --> M[组装活动创建请求]
    M --> N[调用 ProductEventService.create]
    N --> O[返回成功]

    B -->|用户不存在| Z[抛出 BadRequestException]
    C -->|URL 无效| Z
    E -->|抓取失败| Z
    I -->|无票务信息| Z
    N -->|创建失败| Z

步骤 1:用户验证

代码位置: src/web-scraper/base-event-import.service.ts:63

protected async validateUser(userId: string) {
  const pearUser = await this.userMetaRepository.findUserById(userId);
  if (!pearUser) {
    throw new BadRequestException(`Invalid user ID: ${userId}`);
  }
  return pearUser;
}

业务逻辑:

  • 通过 UserMetaRepository.findUserById() 查询用户是否存在
  • 用户不存在时抛出 BadRequestException,错误消息:Invalid user ID: {userId}

步骤 2:URL 验证与规范化

代码位置: src/web-scraper/base-event-import.service.ts:74

protected normalizeUrl(url: string): string {
  return url.includes('://') ? url : `https://${url}`;
}

业务逻辑:

  • 自动为 URL 添加 https:// 前缀(如果缺失)
  • 支持用户输入不带协议的 URL(如 eventbrite.com/e/xxx

平台特定验证

Eventbrite (src/web-scraper/eventbrite-scraper.service.ts:42):

// 域名验证
if (
  parsedUrl.hostname !== 'eventbrite.com' &&
  !parsedUrl.hostname.endsWith('.eventbrite.com')
) {
  throw new BadRequestException(`Invalid URL: ${normalizedUrl}`);
}

// 路径验证 - 必须以 /e 开头
const parts = parsedUrl.pathname.split('/');
if (parts[1] !== 'e') {
  throw new BadRequestException(`It is not a valid ticketing page`);
}

DICE.fm (src/web-scraper/dice-fm-scraper.service.ts:91):

if (
  parsedUrl.hostname !== 'dice.fm' &&
  !parsedUrl.hostname.endsWith('.dice.fm')
) {
  throw new BadRequestException(`Invalid URL: ${normalizedUrl}`);
}

PoshVIP (src/web-scraper/posh-vip-scraper.service.ts:68):

if (
  parsedUrl.hostname !== 'posh.vip' &&
  !parsedUrl.hostname.endsWith('.posh.vip')
) {
  throw new BadRequestException(`Invalid URL: ${normalizedUrl}`);
}

// 路径验证: /e/{eventSlug}
const parts = parsedUrl.pathname.split('/');
if (parts[1] !== 'e' || parts.length < 3) {
  throw new BadRequestException(`It is not a valid PoshVip event page`);
}

步骤 3:数据抓取

Eventbrite 数据抓取

代码位置: src/web-scraper/eventbrite-scraper.service.ts:103

抓取方式: 从页面 <script> 标签中提取 window.__SERVER_DATA__

private async fetchEventbriteEventData(
  normalizedUrl: string,
): Promise<EventBriteEvent | null> {
  const eventbriteResponse = await axios.get<string>(normalizedUrl, {
    headers: {
      'User-Agent':
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...',
    },
  });

  const $ = cheerio.load(eventbriteResponse.data);

  $('script').each((_, element) => {
    const scriptContent = $(element).html();
    if (scriptContent && scriptContent.includes('window.__SERVER_DATA__')) {
      const regex = /window\.__SERVER_DATA__\s*=\s*({.*?});/s;
      const match = regex.exec(scriptContent);
      if (match && match[1]) {
        serverData = JSON.parse(match[1]) as EventBriteEvent;
        return false;
      }
    }
  });

  return serverData;
}

数据结构: src/web-scraper/eventbrite-scraper.interface.ts:2

interface EventBriteEvent {
  event: {
    id: string;
    name: string;
    start: { local: string; timezone: string };
    end: { local: string };
  };
  components: {
    eventHero: { items: [{ croppedLogoUrl940: string }] };
    eventMap: {
      venueName: string;
      venueAddress: string;
      location: { latitude: number; longitude: number };
    };
  };
  event_listing_response: {
    tickets: {
      ticketClasses: [{
        quantityRemaining: number;
        variants: [{
          free: boolean;
          name: string;
          totalCost: { value: number }; // 单位:分
        }];
      }];
    };
    structuredContent: {
      modules: [{ text: string }];
    };
  };
}

DICE.fm 数据抓取

代码位置: src/web-scraper/dice-fm-scraper.service.ts:182

两步抓取流程:

  1. 提取 Event ID(从 HTML meta 标签):
private async extractEventIdFromPage(url: string): Promise<string | null> {
  const response = await axios.get<string>(url, {
    headers: {
      'User-Agent': 'Mozilla/5.0...',
      'Accept': 'text/html,application/xhtml+xml...',
    },
  });

  const $ = cheerio.load(response.data);

  // 方式 1: 从 meta 标签提取
  const eventId = $('meta[property="product:retailer_item_id"]').attr('content');
  if (eventId && /^[a-f0-9]{24}$/.test(eventId)) {
    return eventId;
  }

  // 方式 2: 从 JSON-LD script 标签提取
  $('script[type="application/ld+json"]').each((_, element) => {
    const scriptContent = $(element).html();
    if (scriptContent) {
      const jsonData = JSON.parse(scriptContent);
      if (jsonData['@graph']) {
        for (const item of jsonData['@graph']) {
          if (item['@type'] === 'Event' && item.identifier) {
            return item.identifier;
          }
        }
      }
    }
  });

  return null;
}
  1. 调用 API 获取详细数据:
private async scrapeDiceFmEventData(
  eventId: string,
): Promise<DiceFmEventData | null> {
  const apiURL = `https://api.dice.fm/events/${eventId}/ticket_types`;

  const response = await axios.get<DiceFmEventData>(apiURL, {
    headers: {
      'User-Agent': 'Mozilla/5.0...',
      'Accept': 'application/json',
    },
  });

  return response.data;
}

PoshVIP 数据抓取

代码位置: src/web-scraper/posh-vip-scraper.service.ts:155

抓取方式: 从 self.__next_f.push 提取数据

private async scrapePoshVipEventData(
  url: string,
): Promise<PoshVipEventData | null> {
  const response = await axios.get<string>(url, {
    headers: {
      'User-Agent': 'Mozilla/5.0...',
    },
  });

  const $ = cheerio.load(response.data);

  $('script').each((_, element) => {
    const scriptContent = $(element).html();
    if (scriptContent && scriptContent.includes('self.__next_f.push')) {
      const regex = /self\.__next_f\.push\(\[1,\s*"(.+?eventResponse.+?)"\]\)/s;
      const match = regex.exec(scriptContent);
      if (match && match[1]) {
        const unescapedData = match[1]
          .replace(/\\"/g, '"')
          .replace(/\\\\/g, '\\');

        const eventResponseMatch = unescapedData.match(
          /"eventResponse":\s*(\{.*?"kickbackData":\s*\{[^}]*\}[^}]*\})/s,
        );
        if (eventResponseMatch) {
          eventDataFromPage = JSON.parse(eventResponseMatch[1]);
        }
      }
    }
  });

  // 额外调用 API 获取票务信息
  if (eventDataFromPage.eventId) {
    const tickets = await this.getPoshVipTickets(eventDataFromPage.eventId);
    eventDataFromPage.tickets = tickets;
  }

  return eventDataFromPage;
}

步骤 4:数据转换

代码位置: src/web-scraper/base-event-import.service.ts:210

将各平台的数据转换为标准的 EventImportData 格式:

interface EventImportData {
  title: string;
  description?: string;
  venue: string;
  location: string;
  startDateDisplay: string;  // 格式: YYYY-MM-DD HH:mm:ss
  endDateDisplay: string;
  posterUrl?: string;
  timezone?: PrismaJson.Timezone;
  coordinates?: { lat: number; lng: number };
  tickets: TicketImportData[];
  lineup?: LineupImportData[];
}

interface TicketImportData {
  title: string;
  price: number;
  description?: string;
  quantityAvailable?: number;
  validType?: string;
}

转换示例(Eventbrite):

const standardEventData: EventImportData = {
  title: decodeURI(finalServerData.event.name),
  description: this.formatEventbriteDescription(finalServerData),
  venue: finalServerData.components.eventMap.venueName,
  location: finalServerData.components.eventMap.venueAddress,
  startDateDisplay: finalServerData.event.start.local.replace('T', ' '),
  endDateDisplay: finalServerData.event.end.local.replace('T', ' '),
  posterUrl: finalServerData.components.eventHero.items[0].croppedLogoUrl940,
  coordinates: {
    lat: finalServerData.components.eventMap.location.latitude,
    lng: finalServerData.components.eventMap.location.longitude,
  },
  tickets: this.extractEventbriteTickets(finalServerData),
};

步骤 5:海报上传

代码位置: src/web-scraper/base-event-import.service.ts:161

protected async handlePosterUpload(
  posterUrl: string,
  productCreateRequest: ProductEventCreateRequest,
): Promise<void> {
  const uploadPoster = await this.cloudinaryService.upload(
    decodeURI(posterUrl),
    { resource_type: 'image' },
  );

  productCreateRequest.poster = {
    src: uploadPoster.secure_url,
    width: uploadPoster.width,
    height: uploadPoster.height,
    mediaType: MediaType.IMAGE,
    mediaSrc: undefined as unknown as string,
    mediaDuration: 0,
  };
}

业务逻辑:

  • 使用 CloudinaryService 上传外部图片到 Katana 的 Cloudinary 账户
  • 获取图片的宽高信息并设置到媒体对象
  • 解码 URL 中的特殊字符(如 %20 → 空格)

步骤 6:时区处理

代码位置: src/web-scraper/base-event-import.service.ts:183

protected async handleTimezone(
  eventData: EventImportData,
  productCreateRequest: ProductEventCreateRequest,
): Promise<void> {
  if (eventData.timezone) {
    // 优先使用页面中的时区
    productCreateRequest.timezone = eventData.timezone;
  } else if (eventData.coordinates) {
    // 如果没有时区但有坐标,通过 MapService 反查
    productCreateRequest.timezone = await this.getTimezoneByCoordinates(
      eventData.coordinates.lat,
      eventData.coordinates.lng,
    );
  }
}

protected async getTimezoneByCoordinates(
  lat: number,
  lng: number,
): Promise<PrismaJson.Timezone> {
  return this.mapService.getTimeZoneByLatAndLng(lat, lng);
}

业务逻辑:

  • 优先级 1: 使用页面返回的时区信息(如 Eventbrite 的 event.start.timezone
  • 优先级 2: 如果没有时区但有经纬度坐标,调用 MapService 通过 Google Timezone API 反查
  • 时区格式: PrismaJson.Timezone(包含 timeZoneId、offset 等信息)

步骤 7:票价与手续费计算

代码位置: src/web-scraper/base-event-import.service.ts:81

protected async processTicketPriceAndFees(
  price: number,
  customFeeConfig: PrismaJson.CustomFeeConfig,
  user: UserEntity,
): Promise<{ ticketPrice: number; fees: PrismaJson.TransactionFee }> {
  let ticketPrice = 0;
  let fees: PrismaJson.TransactionFee = {
    platformFee: 0,
    customFee: 0,
    customFeeBreakdown: {},
    transactionItemFee: 0,
  };

  if (price > 0) {
    // 从总价中分离手续费
    fees = await this.transactionFeeService.calcTransactionItemFeeFromFinalPrice(
      price,
      ProductType.TICKET,
      customFeeConfig,
      user!.transactionFeeConfig,
    );
    // 票面价格 = 总价 - 手续费
    ticketPrice = Money.subtract(price, fees.transactionItemFee);
  }

  return { ticketPrice, fees };
}

业务逻辑:

  • 输入: 外部平台显示的最终售价(包含手续费)
  • 计算: 调用 TransactionFeeService.calcTransactionItemFeeFromFinalPrice() 反向计算
    • transactionItemFee: 手续费金额
    • ticketPrice: 去除手续费后的票面价格
  • 免费票: price = 0 时,所有费用字段均为 0

步骤 8:票务描述处理

代码位置: src/web-scraper/base-event-import.service.ts:139

protected processTicketDescription(description?: string) {
  const bodyText = description || '';
  const bodyHtml = bodyText
    ? `<p class=\\"katana__paragraph katana__paragraph--align-left\\" dir=\\"ltr\\"><span style=\\"white-space: pre-wrap;\\">${bodyText}</span></p>`
    : '';
  const bodyJson = bodyText
    ? `{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"${bodyText}\\",\\"type\\":\\"extended-text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"katana-paragraph\\",\\"version\\":1,\\"textFormat\\":0,\\"textStyle\\":\\"\\"}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}`
    : '';

  return { bodyText, bodyHtml, bodyJson };
}

业务逻辑:

  • bodyText: 纯文本描述
  • bodyHtml: HTML 格式(用于邮件模板)
  • bodyJson: Lexical 富文本 JSON 格式(用于前端编辑器)

步骤 9:票务图片处理

代码位置: src/web-scraper/base-event-import.service.ts:111

protected createTicketImages(posterUrl?: string) {
  if (posterUrl) {
    return [{
      src: posterUrl,
      height: 630,
      width: 1200,
      position: 1,
      mediaType: MediaType.IMAGE,
    }];
  }

  // 默认票务图片
  return [{
    src: 'https://res.cloudinary.com/dr9io1zjv/v1755656046/uploaded_images/cg5igrjqku4omywtu0ct.png',
    height: 1000,
    width: 1000,
    position: 1,
    mediaType: MediaType.IMAGE,
  }];
}

业务逻辑:

  • 如果有活动海报,使用活动海报作为票务图片
  • 否则使用默认的票务图片(Cloudinary 上的通用图片)

步骤 10:库存数量处理

代码位置: src/web-scraper/base-event-import.service.ts:279

const quantity = ticket.quantityAvailable ?? (ticket.validType === 'VALID' ? 100 : 0);

业务逻辑:

  • 优先级 1: 使用平台返回的 quantityAvailable
  • 优先级 2: 如果平台未返回数量,根据 validType 判断:
    • validType === 'VALID': 默认库存 100
    • 其他情况: 默认库存 0

步骤 11:活动创建

代码位置: src/web-scraper/base-event-import.service.ts:325

protected async createEventProduct(
  productCreateRequest: ProductEventCreateRequest,
  userId: string,
): Promise<void> {
  try {
    await this.productEventService.create(productCreateRequest, userId);
    console.log(`Successfully created event`);
  } catch (error) {
    console.error('Error creating product event:', error);
    throw new BadRequestException(
      `Failed to create event: ${(error as Error).message}`,
    );
  }
}

调用链:

BaseEventImportService.createEventProduct()
  ↓
ProductEventService.create()  // src/product-event/product.event.service.ts
  ↓
ProductEventV2Service.createEvent()  // src/product-event/v2/product.event.v2.service.ts:98
  ↓
doCreateEvent()  // src/product-event/v2/product.event.v2.service.ts:152
  ↓
repo.createV2()  // 创建 Event 实体

创建逻辑 (src/product-event/v2/product.event.v2.service.ts:152):

async doCreateEvent(request: CreateEventV2Request, userId: string) {
  await this.checkCreateRequest(request);
  this.enrichWithNormalizeLocationDetailsOrThrow(request);

  let event;
  if (request.id) {
    // 更新现有活动
    const existingEvent = await this.repo.findById(request.id);
    if (!existingEvent) {
      throw new ResourceNotFound({ message: `Event ${request.id} not found` });
    }
    if (existingEvent.curatorId !== userId) {
      throw new BadRequestException('Access denied: You do not own this event');
    }
    event = await this.repo.updateV2(request.id, request);
  } else {
    // 创建新活动
    event = await this.repo.createV2(request, userId);
  }

  // 设置创建步骤为 ADD_EVENT_DETAILS
  if (event.creationStep !== EventCreationStep.COMPLETED) {
    await this.repo.updateCreationStep(
      event.id,
      EventCreationStep.ADD_EVENT_DETAILS,
    );
  }

  const entity = await this.productEventService.findUniqueEventEntityOrThrow(event.id);
  await this.productEventPublisher.onCreate(entity);
  return entity;
}

异常场景

错误响应格式

{
  "statusCode": 400,
  "message": "错误描述",
  "error": "Bad Request"
}

异常场景清单

HTTP 状态码 错误场景 错误信息 代码位置
400 用户 ID 无效 Invalid user ID: {userId} base-event-import.service.ts:66
400 URL 格式错误 Invalid URL: {url} eventbrite-scraper.service.ts:46
400 非有效票务页面 It is not a valid ticketing page eventbrite-scraper.service.ts:53
400 无法获取页面数据 Unable to retrieve server data from the link eventbrite-scraper.service.ts:60
400 无票务信息 No ticket information is available. The event may have expired or is no longer valid eventbrite-scraper.service.ts:68
400 活动创建失败 Failed to create event: {error message} base-event-import.service.ts:334
400 Event ID 未找到 Event ID not found in page dice-fm-scraper.service.ts:110
400 DICE.fm 活动不存在 Event not found on DICE.fm dice-fm-scraper.service.ts:291
401 认证失败 Client ID 或 Secret 不匹配 admin.guard.ts:21
403 权限不足 非 Admin 角色 -
500 JSON 解析失败 (记录到 console,返回通用错误) eventbrite-scraper.service.ts:127

Eventbrite 特定错误

  1. 域名验证失败 (eventbrite-scraper.service.ts:42)

    • 域名不是 eventbrite.com 或其子域名
  2. 路径格式错误 (eventbrite-scraper.service.ts:52)

    • URL 路径第二段不是 e
    • 例如:https://eventbrite.com/invalid/tickets 会失败
  3. 页面数据缺失 (eventbrite-scraper.service.ts:119)

    • 页面中不存在 window.__SERVER_DATA__ 变量
    • JSON 解析失败
  4. 活动已过期 (eventbrite-scraper.service.ts:66)

    • ticketClasses 数组为空
    • quantityRemaining 为 0

DICE.fm 特定错误

  1. Event ID 提取失败 (dice-fm-scraper.service.ts:109)

    • Meta 标签中未找到 Event ID
    • JSON-LD 中也未找到 Event ID
    • Event ID 格式不是 24 位 hex 字符串
  2. API 调用失败 (dice-fm-scraper.service.ts:290)

    • 404: 活动不存在
    • 其他网络错误

PoshVIP 特定错误

  1. Event Slug 提取失败 (posh-vip-scraper.service.ts:82)

    • URL 路径格式不正确(需要 /e/{slug}
  2. 数据抓取失败 (posh-vip-scraper.service.ts:208)

    • 页面中未找到 self.__next_f.push 数据
    • JSON 解析失败

示例

成功请求示例

curl -X POST 'https://api.example.com/external-event-import/admin/ad57de31-bf92-413b-ae4d-8609b5ff0680' \
  -H 'Content-Type: application/json' \
  -H 'Pear-Client-Id: your-client-id' \
  -H 'Pear-Client-Secret: your-client-secret' \
  -H 'timezone: Asia/Shanghai' \
  -d '{
    "type": "eventbrite",
    "url": "https://www.eventbrite.com/e/the-association-cocktail-classes-tickets-164264039163"
  }'

Eventbrite 响应示例

成功: HTTP 200 OK(无响应体)

失败示例 1 - 无效用户:

{
  "statusCode": 400,
  "message": "Invalid user ID: ad57de31-bf92-413b-ae4d-8609b5ff0680",
  "error": "Bad Request"
}

失败示例 2 - 无效 URL:

{
  "statusCode": 400,
  "message": "Invalid URL: https://example.com/event",
  "error": "Bad Request"
}

失败示例 3 - 非票务页面:

{
  "statusCode": 400,
  "message": "It is not a valid ticketing page",
  "error": "Bad Request"
}

失败示例 4 - 无票务信息:

{
  "statusCode": 400,
  "message": "No ticket information is available. The event may have expired or is no longer valid",
  "error": "Bad Request"
}

DICE.fm 请求示例

curl -X POST 'https://api.example.com/external-event-import/admin/ad57de31-bf92-413b-ae4d-8609b5ff0680' \
  -H 'Content-Type: application/json' \
  -H 'Pear-Client-Id: your-client-id' \
  -H 'Pear-Client-Secret: your-client-secret' \
  -d '{
    "type": "dicefm",
    "url": "https://dice.fm/event/lydia-lunchs-big-sexy-noise-mellowdeath-8th-feb-neue-zukunft-berlin-tickets-6929974d8aacf600016144de"
  }'

PoshVIP 请求示例

curl -X POST 'https://api.example.com/external-event-import/admin/ad57de31-bf92-413b-ae4d-8609b5ff0680' \
  -H 'Content-Type: application/json' \
  -H 'Pear-Client-Id: your-client-id' \
  -H 'Pear-Client-Secret: your-client-secret' \
  -d '{
    "type": "poshvip",
    "url": "https://posh.vip/e/west-village-halloween-bar-fest"
  }'

注意事项

[!important] 重要提示

1. 时区处理

  • 活动开始/结束时间使用的是平台的本地时间格式
  • Eventbrite: YYYY-MM-DDTHH:mm:ss(需转换为 YYYY-MM-DD HH:mm:ss
  • DICE.fm: ISO 8601 格式带时区(需移除时区信息)
  • 如果平台未返回时区,通过经纬度坐标反查

2. 图片上传

  • 外部海报 URL 会通过 Cloudinary 重新上传
  • 避免直接使用外部链接,确保图片永久可用
  • 解码 URL 中的特殊字符(如 %20 → 空格)

3. 手续费计算

  • 票价中包含手续费,创建产品时会自动分离计算
  • 调用 TransactionFeeService.calcTransactionItemFeeFromFinalPrice() 反向计算
  • ticketPrice = 总价 - transactionItemFee

4. 库存默认值

  • 如果平台未返回库存数量:
    • validType === 'VALID': 默认 100
    • 其他情况: 默认 0
  • DICE.fm 使用 limits.max_increments 作为库存

5. HTML 清理

  • 活动描述中的 HTML 标签会被清理
  • 保留换行和段落结构
  • 移除 <p><br> 以外的标签

6. 认证凭据

  • Pear-Client-IdPear-Client-Secret 必须与环境变量中的值完全匹配
  • 认证在 AdminGuard 中验证(src/auth/admin.guard.ts

7. 活动创建步骤

  • 创建后 creationStep 自动设为 ADD_EVENT_DETAILS
  • 前端需要继续完成后续步骤:
    • 上传媒体 (UPLOAD_EVENT_COVER)
    • 创建票务 (ADD_EVENT_TICKETS)
    • 创建阵容 (ADD_EVENT_LINEUP)
    • 选择模块 (CHOOSE_MODULES)

8. 错误处理

  • 网络错误会重试 3 次(axios 默认配置)
  • JSON 解析失败会记录到 console
  • 所有业务错误统一返回 BadRequestException

支持的平台差异

Eventbrite

属性
数据源 window.__SERVER_DATA__ 全局变量
URL 要求 必须包含 /e/ 路径段
票种支持 支持多票种变体 (variants)
时区 从页面数据直接获取 (event.start.timezone)
价格单位 分(需要除以 100)
库存 ticketClasses.quantityRemaining
海报 components.eventHero.items[0].croppedLogoUrl940

DICE.fm

属性
数据源 Meta 标签 + 公开 API
URL 要求 https://dice.fm/event/*
Event ID 24 位 hex 字符串(从 meta 标签或 JSON-LD 提取)
API 端点 https://api.dice.fm/events/{eventId}/ticket_types
价格单位 分(需要除以 100)
库存 ticket_types.limits.max_increments
海报 images.landscapeimages.square
时区 dates.timezone
阵容支持 summary_lineup.top_artists(海报、顺序、是否 headliner)

PoshVIP

属性
数据源 self.__next_f.push + 内部 API
URL 要求 https://posh.vip/e/{eventSlug}
Event ID eventId 字段
API 端点 https://posh.vip/next-bff/events.getEventTickets?input={...}
价格单位 元(直接使用)
库存 tickets.quantityAvailable
海报 flyer 字段
时区 timezone 字段
描述处理 合并 shortDescriptiondescription

RA.co / Shotgun

[!warning] 开发中 这两个平台的抓取服务已定义但未完全实现,文档待补充。


相关文件

文件路径 说明
src/web-scraper/admin/event-scraper.admin.controller.ts 控制器,处理请求路由
src/web-scraper/eventbrite-scraper.service.ts Eventbrite 抓取服务
src/web-scraper/dice-fm-scraper.service.ts DICE.fm 抓取服务
src/web-scraper/posh-vip-scraper.service.ts PoshVIP 抓取服务
src/web-scraper/base-event-import.service.ts 基础服务类,包含通用逻辑
src/web-scraper/web-scraper.interface.ts 接口定义(EventImportRequest、EventImportType)
src/web-scraper/eventbrite-scraper.interface.ts Eventbrite 数据结构定义
src/auth/admin.guard.ts 管理员认证守卫

依赖服务

服务 用途 代码位置
ProductEventService 创建活动及票务产品 src/product-event/product.event.service.ts
ProductEventV2Service V2 活动创建逻辑 src/product-event/v2/product.event.v2.service.ts
CloudinaryService 上传海报图片 src/cloudinary/cloudinary.service.ts
MapService 根据经纬度查询时区 src/map/map.service
TransactionFeeService 计算票务手续费 src/transaction-fee/transaction-fee.service.ts
UserMetaRepository 验证用户存在性 src/user-meta/userMeta.repository.ts

更新日志

版本 日期 变更内容
1.0.0 2026-03-03 初始版本,支持 Eventbrite、DICE.fm、PoshVIP 三个平台

参考资料

results matching ""

    No results matching ""